package expo.modules.updates

import android.app.Activity
import android.content.Context
import android.net.Uri
import android.os.Bundle
import com.facebook.react.bridge.ReactContext
import com.facebook.react.devsupport.interfaces.DevSupportManager
import expo.modules.easclient.EASClientID
import expo.modules.kotlin.exception.CodedException
import expo.modules.kotlin.exception.toCodedException
import expo.modules.updates.db.BuildData
import expo.modules.updates.db.DatabaseHolder
import expo.modules.updates.db.UpdatesDatabase
import expo.modules.updates.db.entity.UpdateEntity
import expo.modules.updates.events.IUpdatesEventManager
import expo.modules.updates.events.UpdatesEventManager
import expo.modules.updates.launcher.Launcher.LauncherCallback
import expo.modules.updates.loader.FileDownloader
import expo.modules.updates.logging.UpdatesErrorCode
import expo.modules.updates.logging.UpdatesLogReader
import expo.modules.updates.logging.UpdatesLogger
import expo.modules.updates.manifest.EmbeddedManifestUtils
import expo.modules.updates.manifest.ManifestMetadata
import expo.modules.updates.procedures.CheckForUpdateProcedure
import expo.modules.updates.procedures.FetchUpdateProcedure
import expo.modules.updates.procedures.RelaunchProcedure
import expo.modules.updates.procedures.StartupProcedure
import expo.modules.updates.reloadscreen.ReloadScreenManager
import expo.modules.updates.selectionpolicy.SelectionPolicy
import expo.modules.updates.selectionpolicy.SelectionPolicyFactory
import expo.modules.updates.statemachine.UpdatesStateMachine
import expo.modules.updates.statemachine.UpdatesStateValue
import expo.modules.updatesinterface.UpdatesMetricsInterface
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
import java.lang.ref.WeakReference
import java.util.UUID
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.time.DurationUnit
import kotlin.time.toDuration

/**
 * Updates controller for applications that have updates enabled and properly-configured.
 */
class EnabledUpdatesController(
  private val context: Context,
  private var updatesConfiguration: UpdatesConfiguration,
  override val updatesDirectory: File
) : IUpdatesController, UpdatesMetricsInterface {
  /** Keep the activity for [RelaunchProcedure] to relaunch the app. */
  private var weakActivity: WeakReference<Activity>? = null
  private val logger = UpdatesLogger(context.filesDir)
  override val eventManager: IUpdatesEventManager = UpdatesEventManager(logger)

  private val selectionPolicy: SelectionPolicy
    get() = SelectionPolicyFactory.createFilterAwarePolicy(updatesConfiguration.getRuntimeVersion(), updatesConfiguration)
  private val controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
  private val stateMachine = UpdatesStateMachine(logger, eventManager, UpdatesStateValue.entries.toSet(), controllerScope)
  private val fileDownloader: FileDownloader
    get() = FileDownloader(
      context.filesDir,
      EASClientID(context).uuid.toString(),
      updatesConfiguration,
      logger,
      databaseHolder.database
    )
  private val databaseHolder = DatabaseHolder(UpdatesDatabase.getInstance(context, Dispatchers.IO))
  private val startupFinishedDeferred = CompletableDeferred<Unit>()
  private val startupFinishedMutex = Mutex()
  override val reloadScreenManager = ReloadScreenManager()

  private fun purgeUpdatesLogsOlderThanOneDay() {
    UpdatesLogReader(context.filesDir).purgeLogEntries {
      if (it != null) {
        logger.error("UpdatesLogReader: error in purgeLogEntries", it, UpdatesErrorCode.Unknown)
      }
    }
  }

  private var isStarted = false
  private var isStartupFinished = false
  private var startupStartTimeMillis: Long? = null
  private var startupEndTimeMillis: Long? = null

  @Synchronized
  private fun onStartupProcedureFinished() {
    controllerScope.launch {
      startupFinishedMutex.withLock {
        if (!startupFinishedDeferred.isCompleted) {
          startupFinishedDeferred.complete(Unit)
        }
      }
    }
    isStartupFinished = true
    startupEndTimeMillis = System.currentTimeMillis()
  }

  private val startupProcedure = StartupProcedure(
    context,
    updatesConfiguration,
    databaseHolder,
    updatesDirectory,
    fileDownloader,
    selectionPolicy,
    logger,
    object : StartupProcedure.StartupProcedureCallback {
      override fun onFinished() {
        onStartupProcedureFinished()
      }

      override fun onRequestRelaunch(shouldRunReaper: Boolean, callback: LauncherCallback) {
        relaunchReactApplication(shouldRunReaper, callback)
      }
    },
    controllerScope
  )

  private val launchedUpdate
    get() = startupProcedure.launchedUpdate
  private val launchDuration
    get() = startupStartTimeMillis?.let { start -> startupEndTimeMillis?.let { end -> (end - start).toDuration(DurationUnit.MILLISECONDS) } }
  private val isUsingEmbeddedAssets
    get() = startupProcedure.isUsingEmbeddedAssets
  private val localAssetFiles
    get() = startupProcedure.localAssetFiles

  override val launchAssetFile: String?
    get() {
      runBlocking {
        startupFinishedDeferred.await()
      }
      return startupProcedure.launchAssetFile
    }

  override val bundleAssetName: String?
    get() = startupProcedure.bundleAssetName

  override fun onEventListenerStartObserving() {
    stateMachine.sendContextToJS()
  }

  override fun onDidCreateDevSupportManager(devSupportManager: DevSupportManager) {
    startupProcedure.onDidCreateDevSupportManager(devSupportManager)
  }

  override fun onDidCreateReactInstance(reactContext: ReactContext) {
    weakActivity = WeakReference(reactContext.currentActivity)
  }

  override fun onReactInstanceException(exception: Exception) {
    startupProcedure.onReactInstanceException(exception)
  }

  override val isActiveController = true

  @Synchronized
  override fun start() {
    if (isStarted) {
      return
    }
    isStarted = true
    startupStartTimeMillis = System.currentTimeMillis()

    purgeUpdatesLogsOlderThanOneDay()

    if (!updatesConfiguration.hasUpdatesOverride) {
      BuildData.ensureBuildDataIsConsistent(updatesConfiguration, databaseHolder.database)
    }

    stateMachine.queueExecution(startupProcedure)
  }

  private fun relaunchReactApplication(shouldRunReaper: Boolean, callback: LauncherCallback) {
    val procedure = RelaunchProcedure(
      context,
      weakActivity,
      updatesConfiguration,
      logger,
      databaseHolder,
      updatesDirectory,
      fileDownloader,
      selectionPolicy,
      getCurrentLauncher = { startupProcedure.launcher!! },
      setCurrentLauncher = { currentLauncher -> startupProcedure.setLauncher(currentLauncher) },
      shouldRunReaper = shouldRunReaper,
      reloadScreenManager = reloadScreenManager,
      callback,
      controllerScope
    )
    stateMachine.queueExecution(procedure)
  }

  private fun getEmbeddedUpdate(): UpdateEntity? {
    return EmbeddedManifestUtils.getEmbeddedUpdate(context, updatesConfiguration)?.updateEntity
  }

  override fun getConstantsForModule(): IUpdatesController.UpdatesModuleConstants {
    return IUpdatesController.UpdatesModuleConstants(
      launchedUpdate = launchedUpdate,
      launchDuration = launchDuration,
      embeddedUpdate = getEmbeddedUpdate(),
      emergencyLaunchException = startupProcedure.emergencyLaunchException,
      isEnabled = true,
      isUsingEmbeddedAssets = isUsingEmbeddedAssets,
      runtimeVersion = updatesConfiguration.runtimeVersionRaw,
      checkOnLaunch = updatesConfiguration.checkOnLaunch,
      requestHeaders = updatesConfiguration.requestHeaders,
      localAssetFiles = localAssetFiles,
      shouldDeferToNativeForAPIMethodAvailabilityInDevelopment = false,
      initialContext = stateMachine.context
    )
  }

  override suspend fun relaunchReactApplicationForModule() = suspendCancellableCoroutine { continuation ->
    val canRelaunch = launchedUpdate != null
    if (!canRelaunch) {
      continuation.resumeWithException(object : CodedException("ERR_UPDATES_RELOAD", "Cannot relaunch without a launched update.", null) {})
    } else {
      relaunchReactApplication(
        shouldRunReaper = true,
        object : LauncherCallback {
          override fun onFailure(e: Exception) {
            continuation.resumeWithException(e.toCodedException())
          }

          override fun onSuccess() {
            continuation.resume(Unit)
          }
        }
      )
    }
  }

  override suspend fun checkForUpdate() = suspendCancellableCoroutine { continuation ->
    val procedure = CheckForUpdateProcedure(context, updatesConfiguration, databaseHolder, logger, fileDownloader, selectionPolicy, launchedUpdate) {
      continuation.resume(it)
    }
    stateMachine.queueExecution(procedure)
  }

  override suspend fun fetchUpdate() = suspendCancellableCoroutine { continuation ->
    val procedure = FetchUpdateProcedure(context, updatesConfiguration, logger, databaseHolder, updatesDirectory, fileDownloader, selectionPolicy, launchedUpdate) {
      continuation.resume(it)
    }
    stateMachine.queueExecution(procedure)
  }

  override suspend fun getExtraParams() = suspendCancellableCoroutine { continuation ->
    controllerScope.launch {
      try {
        val result = ManifestMetadata.getExtraParams(
          databaseHolder.database,
          updatesConfiguration
        )
        val resultMap = when (result) {
          null -> Bundle()
          else -> {
            Bundle().apply {
              result.forEach {
                putString(it.key, it.value)
              }
            }
          }
        }
        continuation.resume(resultMap)
      } catch (e: Exception) {
        continuation.resumeWithException(e.toCodedException())
      }
    }
  }

  override suspend fun setExtraParam(key: String, value: String?) = suspendCancellableCoroutine { continuation ->
    controllerScope.launch {
      runCatching {
        ManifestMetadata.setExtraParam(
          databaseHolder.database,
          updatesConfiguration,
          key,
          value
        )
        continuation.resume(Unit)
      }.onFailure { e ->
        continuation.resumeWithException(e.toCodedException())
      }
    }
  }

  override fun shutdown() {
    controllerScope.cancel()
  }

  override fun setUpdateURLAndRequestHeadersOverride(configOverride: UpdatesConfigurationOverride?) {
    if (!updatesConfiguration.disableAntiBrickingMeasures) {
      throw CodedException("ERR_UPDATES_RUNTIME_OVERRIDE", "Must set disableAntiBrickingMeasures configuration to use updates overriding", null)
    }
    UpdatesConfigurationOverride.save(context, configOverride)
    updatesConfiguration = UpdatesConfiguration.create(context, updatesConfiguration, configOverride)
  }

  override fun setUpdateRequestHeadersOverride(requestHeaders: Map<String, String>?) {
    val isValidRequestHeaders = UpdatesConfiguration.isValidRequestHeadersOverride(
      updatesConfiguration.originalEmbeddedRequestHeaders,
      requestHeaders
    )
    if (!isValidRequestHeaders) {
      throw CodedException("ERR_UPDATES_RUNTIME_OVERRIDE", "Invalid update requestHeaders override: $requestHeaders", null)
    }
    val configOverride = UpdatesConfigurationOverride.saveRequestHeaders(context, requestHeaders)
    updatesConfiguration = UpdatesConfiguration.create(context, updatesConfiguration, configOverride)
  }

  // UpdatesMetricsInterface implementations

  override val runtimeVersion: String?
    get() = updatesConfiguration.runtimeVersionRaw

  override val updateUrl: Uri?
    get() = updatesConfiguration.updateUrl

  override val launchedUpdateId: UUID?
    get() = startupProcedure.launchedUpdate?.id

  override val embeddedUpdateId: UUID?
    get() = getEmbeddedUpdate()?.id

  companion object {
    private val TAG = EnabledUpdatesController::class.java.simpleName
  }
}
